**一句話先講完:**只要一個元件失效就讓使用者工作無法完成,它就是單點故障(SPOF);redundancy 是資源存在,failover 是故障時真的判斷並切換,兩者不是同一件事,這一篇先把「怎麼找出假備援」講清楚。
兩個 API instance 後面接同一個資料庫、同一組憑證、同一個 region 的 provider,架構圖看起來有兩份,失敗時仍可能只倒一次就全倒。
沿 Day 9 的 critical path 問四次:唯一的 instance?唯一的資料?唯一的設定/secret?唯一的外部 provider?最後兩項常被忘記。LLM application 還要問:唯一的 model、唯一的 vector index、唯一的 tool authorization service 是不是讓工作完全停擺?
不是每個 SPOF 都必須消除。成本、資料一致性與產品風險決定優先順序;請把可接受的單點,以及失效時的使用者結果寫下來。
2021 年 10 月 Meta 的全球停機事件是「隱藏 SPOF」的經典案例。架構裡有多個資料中心、多組 DNS 伺服器與跨區域部署;一次骨幹網路維護的錯誤指令,仍讓全球 backbone 離線。DNS 伺服器本身仍在運作,卻無法與資料中心通訊,因而撤回 BGP 廣告並變成「在線但不可達」。事件約持續六小時,最後需由人員到資料中心現場操作才能恢復——內部診斷工具同樣依賴 DNS,工程師連遠端 VPN 都無法登入,恢復路徑與故障路徑纏在一起(⑥ 節會再從「控制面」角度回顧這個案例)。這個案例要檢查的是依賴是否獨立,而非單純計算機器數量。
很多人對 SPOF 的第一直覺是「只有一台伺服器在跑」。這個直覺沒有錯,但只講對了最表層的一種。SPOF 真正的定義比機器數量抽象一層:
只要拿掉某一個元件,使用者原本能完成的工作就變成完不成,這個元件就是 SPOF,不管它背後有幾台機器在撐。
這個定義刻意不提「機器」兩個字,是因為 SPOF 可以是一條網路路徑、一份設定檔、一個 DNS record、一組 credential、一個負責簽發憑證的內部服務。用「路徑」而非「機器」來想,才不會把「加一台機器」誤當成「消除單點」。
看起來有備援:
API pod #1 ─┐
├─→ 同一個 database endpoint(唯一)
API pod #2 ─┘
實際的 SPOF:
database endpoint
兩個 pod 分擔了計算負載,但只要那個 database endpoint 掛掉,兩個 pod 都會同時失敗。這不是「備援沒做好」,而是「備援做在了不會壞的那一層,沒做在真正會壞的那一層」,這句話值得抄在任何架構審查的開頭。
誤解一:多雲/多區域自動代表沒有 SPOF。 Meta 的例子已經示範過:多資料中心、多 DNS 伺服器,仍可能共用一條 backbone。region 數量是必要條件,不是充分條件。
誤解二:SPOF 只存在於基礎設施層。 一份手動維護的 Excel 白名單、一位知道怎麼重啟服務的工程師、一組只有一人知道密碼的管理帳號,都是 SPOF,只是它們不會出現在架構圖裡。Google SRE 把這類人為依賴稱為「bus factor」問題:如果某個人被公車撞了(比喻,不是詛咒),系統還能不能運作。AI 專案常見的版本是:只有一位工程師搞懂 prompt template 為什麼要那樣寫,或只有一人知道 vector index 的 re-embedding pipeline 怎麼跑。
誤解三:備援元件之間互相獨立就夠了。 還要問備援元件依賴的「第三方」是否獨立。兩個 API pod 各自獨立沒錯,但如果兩者都讀同一組 feature flag 服務、同一個 secrets manager、同一個 rate limiter,那組共用服務才是真正決定可用性上限的那個環節。
傳統 SRE 盤點 SPOF 時問的是 instance、data、configuration、external provider。AI 系統的請求路徑比傳統 CRUD 服務多繞了幾層,因此多出四種容易被忽略的「唯一」:
唯一的 model version
→ 換一個 checkpoint,輸出風格、安全過濾、tool-calling 格式可能全部不同
唯一的 vector index / embedding pipeline
→ index 版本落後、embedding model 換了但沒重新索引,等於資料「看起來在」實際上查不到
唯一的 prompt / system instruction 來源
→ 存在單一 config service 裡,該服務掛掉時所有 workflow 同時失去「怎麼問」的能力
唯一的 tool authorization / policy 服務
→ agent 能不能呼叫工具,取決於這個服務是否可達;它掛了不代表 model 掛了,
但使用者體驗到的是同一種「無法完成任務」
這四種「唯一」都不會讓 /healthz 變紅,卻能讓整個 AI workflow 從「回答正確」變成「回答不了」或「回答錯」。Day 12 談過 technical success 與 semantic success 的分層,這裡是同一個分層在 SPOF 盤點上的延伸:傳統 SPOF 盤點問的是「還能不能回應」,AI SPOF 盤點還要多問一句「還能不能回應得對」。
不是每個 SPOF 都必須消除,這句話在 AI 系統裡同樣成立,甚至更重要,因為消除「唯一 model version」的代價,可能是要同時維護兩套 prompt、兩套評估集、兩套安全審查,這筆帳要打在成本與風險對照表上,而不是憑直覺決定「當然要備援」。
在往下讀之前,花五分鐘對照你手邊正在維護(或正在設計)的一個 AI 服務,回答下面八個問題,不需要精確答案,只需要誠實回答「知道」或「不知道」:
1. 這個服務有幾個 model provider?是哪幾家?
2. 如果只有一家,換一家的成本(工程與資料治理)大概是多少?
3. vector index 有沒有複本?複本跟正本共用同一個 ingestion pipeline 嗎?
4. prompt / system instruction 存在哪裡?那個地方掛掉,服務還能不能運作?
5. tool authorization(誰可以呼叫哪個工具)由哪個服務決定?
6. 上一次「這裡故障會怎樣」的討論,是什麼時候?誰在場?
7. 如果 model provider 的 status page 顯示 outage,你的 on-call 流程是什麼?
8. 這個服務的架構圖,是三個月前畫的,還是上禮拜畫的?
如果八題裡有超過三題回答「不知道」,不代表這個服務做得不好,而是代表它現在正處於「redundancy 幻覺」最容易發生的狀態,架構圖看起來合理、日常運作也沒出過大問題,但沒有人真正驗證過故障發生時系統會怎麼反應。這份小測驗刻意設計成不需要任何工具、不需要存取權限就能做,目的是讓它成為讀完這一節後,可以立刻、免費做的第一個動作。
redundancy 是有替代資源;failover 是故障時真的改走替代資源。後者需要 health 判斷、路由、資料一致性與回切策略。更糟的是,錯誤 health check 可能把健康流量切到壞節點,於是備援系統變成事故放大器。
這句話值得拆開來看,因為它是本篇最容易被略讀過去、卻最常在事故現場被驗證成真的一句:
redundancy = 資源存在
(有第二台機器、第二個 provider、第二份資料複本)
failover = 決策 + 執行
(偵測 → 判斷 → 路由 → 確認一致性 → 之後還要回切)
redundancy ≠ failover
redundancy 是 failover 的必要條件,不是充分條件
redundancy 是一個「靜態事實」:你可以用架構圖或資源清單直接證明它存在,「這裡有兩台」「這裡有兩個 provider key」。failover 卻是一個「動態行為」:只有在故障真的發生、系統真的做出正確判斷並成功切換之後,你才能證明它存在。這也是為什麼很多團隊會說「我們有備援」卻答不出「上一次備援真的接手是什麼時候」,因為他們驗證的一直是前者,從沒驗證過後者。
對 AI 而言,fallback model 也不是自動安全:模型能力、輸出格式、資料地域、tool-calling 支援可能不同。切換前要決定哪些任務可以降級、哪些必須直接拒絕。
「錯誤 health check 可能把健康流量切到壞節點」這句話聽起來抽象,具體發生的方式通常是下面三種之一:
情境一:假陽性(false positive)
健康的節點被誤判為不健康
→ 流量被切走,剩下節點承受雙倍負載
→ 剩下節點也開始變慢,觸發第二輪誤判
→ 「級聯式健康檢查誤判」
情境二:假陰性(false negative)
不健康的節點被誤判為健康
→ 流量繼續(或被切)進去,使用者拿到錯誤或逾時回應
→ health check 本身看的訊號(如 TCP port 有開)
和使用者真正在意的訊號(如能不能完成請求)不是同一件事
情境三:健康檢查本身變成負載來源
→ 高頻的深度健康檢查(例如每秒呼叫一次真實 LLM)
→ 在依賴已經吃緊時,健康檢查請求本身變成壓垮駱駝的最後一根稻草
情境一是「備援系統變成事故放大器」最常見的具體樣貌:health check 的敏感度設定得太激進(例如單次逾時就判定不健康),會讓系統在流量本來就有波動的正常時段裡,不斷把流量在節點之間甩來甩去,這種行為有個常見的名字叫 flapping,本篇 ⑩ 反例會用組合式健康訊號來處理,這裡先留一個印象:health check 的設計本身,就是一個需要被測試、被告警、被觀察歷史紀錄的子系統,不是接上一支 /healthz 就算完工。
多 provider 並不自動等於可靠。實作時最常掉進三個陷阱:把 429 與 500 視為同一種錯誤而來回切換、沒有重試上限而把等待時間與成本一起放大、忽略 fallback model 的輸出格式或 tool-calling 能力不同。正確設計要分清楚「可以重試」「可以切換」和「必須失敗」,再為每一種決定 bounded attempt、latency budget、使用者結果與可觀測證據。
把三個陷阱拆開看,會發現它們其實對應三種不同性質的錯誤:
429 Too Many Requests → 我方(或對方)已達配額上限,重試只會讓情況更糟
500 Internal Server Error → 對方發生非預期例外,短時間內重試「可能」有效
逾時(timeout) → 不確定對方有沒有處理完,重試前要考慮冪等性
把 429 當 500 處理,最常見的後果是:系統以為「再試一次就好」,於是立刻重試,卻正好撞上對方的 rate limit 視窗還沒重置,於是又拿到一次 429,形成一個自己製造出來的重試迴圈,這正是 Day 11 談過的 retry storm 在 provider 層級的翻版,只是這次不是應用程式的 bug,而是路由邏輯本身把兩種語意完全不同的錯誤,塞進了同一個 except Exception 分支。
用程式碼對照會更清楚兩種處理方式的差異:
# 反例:429 與 500 用同一套邏輯處理
def call_with_naive_retry(client, prompt):
for attempt in range(3):
try:
return client.generate(prompt)
except ProviderError:
continue # 不管是 429 還是 500,都立刻重試
return client_fallback.generate(prompt)
# 對照:依語意分開處理
def call_with_classified_retry(client, fallback_client, prompt):
for attempt in range(2):
try:
return client.generate(prompt)
except RateLimited as error:
# 429:尊重對方給的 retry-after,不是立刻重試
time.sleep(error.retry_after_seconds)
except ServerError:
# 500:短暫 backoff 後再試一次,仍在合理範圍內
time.sleep(0.5 * (attempt + 1))
# 兩種情況都用盡重試後,才考慮是否符合 fallback 資格
return fallback_client.generate(prompt)
RateLimited 分支裡的 retry_after_seconds 是關鍵:大多數 provider 在回傳 429 時,會在 header 附上「你該等多久再試」的明確數字,直接尊重這個數字,比自己猜一個 backoff 時間更準確,也更不容易在下一個 rate limit 視窗又撞上去。這一段程式碼的重試次數刻意寫得很保守(range(2)),對照 ⑤ 節的時間預算公式,重試次數與每次的等待時間,永遠要回頭檢查 sum(每次嘗試的時間) ≤ request deadline 這條不等式。
active/passive 只有 primary 接流量,secondary 等待接手。它的切換條件、資料同步延遲與回切規則要寫清楚;切換期間若無法保證資料一致,使用者應拿到明確的暫時不可用狀態,不該默默重送寫入。
active/passive 的資料流
正常時:
Client → Router → Primary(接受讀寫)
│
▼(非同步或同步複製)
Secondary(待命,不接流量)
切換後:
Client → Router → Secondary(接手讀寫)
│
▼
Primary(修復中,稍後重新同步)
這張圖裡最容易被忽略的字是「非同步或同步」,這五個字直接決定了切換當下會不會遺失資料。同步複製保證 secondary 永遠跟 primary 一致,但代價是每次寫入都要等兩邊都確認才算完成,延遲會升高;非同步複製延遲低,但代表 secondary 手上的資料可能落後 primary 幾秒到幾分鐘,一旦在這個落差期間切換,落後的那幾秒寫入就會憑空消失。這不是「選錯了」,而是每一種選擇都要有人明確簽字承認這個取捨,而不是被預設值悄悄決定。
active/active 讓多個節點同時接流量,沒有「等著被叫醒」的 secondary,卻要處理跨節點路由、衝突、資料一致性與部分區域失敗。某個節點不健康時,流量只應移出該節點;禁止把寫入任意重送到另一端,除非操作具備 idempotency 與一致性策略。
active/active 的資料流
正常時:
Client A → Router A → Node A(接受讀寫)─┐
Client B → Router B → Node B(接受讀寫)─┼─ 雙向同步 / 衝突解決
│
節點 A 不健康時:
Client A → Router A → (移出 Node A,改路由到 Node B 或直接降級)
Client B → Router B → Node B(持續服務,不受影響)
active/active 看起來比 active/passive「更進步」,沒有閒置資源、沒有切換延遲、理論上可用率更高。但它把 active/passive 原本集中在「切換那一刻」的複雜度,攤開成「每一刻都在發生」的複雜度:只要兩個節點同時可寫,衝突就有可能發生,不需要等到故障才出現。這也是為什麼 active/active 不是「active/passive 的進階版」,而是一種需要額外一層資料一致性設計的完全不同架構,選它不該只是因為「聽起來比較高可用」。
「處理衝突」不是一句口號,而是要先選一種具體策略:常見的有 last-write-wins(用時間戳決勝負,簡單但可能悄悄蓋掉較新的寫入)、把每個 key 固定分配給某一節點負責寫入(其他節點只能轉發,等於把 active/active 局部退化成 active/passive)、或用 CRDT 之類的資料結構讓合併結果天生無衝突。三種都要付出代價:前兩種要接受某種資料遺失或延遲,第三種要接受資料模型被限制。沒有選定策略之前,「兩個節點都能寫」只是一個尚未回答完的問題,不是已經解決的架構。
換成 AI workflow 的語言,這三種策略對應到的具體場景大概是這樣:
last-write-wins
→ 適合:使用者偏好設定、對話 session 的最後一則訊息
→ 不適合:agent 執行中的 tool call 記錄(後蓋前會遺失審計軌跡)
固定分片(每個 key 只由一個節點負責寫)
→ 適合:每個 conversation id 固定路由到同一個節點處理
→ 代價:那個節點掛掉時,該 conversation 仍然無法寫入,
只是換了一種方式重新出現「單點」
CRDT / 天生無衝突的資料結構
→ 適合:計數器型資料(如 token 用量累加)、集合型資料(如已完成的工具呼叫清單)
→ 不適合:需要嚴格因果順序、且順序本身帶有商業意義的資料
(例如一連串會互相影響結果的 agent 決策步驟)
這張對照表的重點不是要你選哪一種,而是提醒你:conflict resolution 策略要對照到具體資料型別去選,不能整個系統只套用一種策略了事。一個 AI 服務裡,對話紀錄、工具呼叫審計、使用者設定、用量計數,很可能需要四種不同的策略,把它們混在同一組 replication 規則裡,通常代表根本沒有人真正想過這個問題。
GitHub 在 2018 年 10 月的一次事故,示範了 failover 機制邏輯完全正確、結果卻依然是災難的情況。東岸資料中心與美東網路樞紐之間發生一次僅 43 秒的網路分區;GitHub 用來管理 MySQL topology 的 Orchestrator(基於 Raft 共識)在分區期間依規則進行 leader 重選,西岸與東岸 public cloud 的節點湊足 quorum,判定應把寫入端切到西岸資料中心。這個決策本身完全符合 Orchestrator 的設計,quorum 判斷、leader election,每一步都做對了。問題是應用層的假設從未被寫進這套 failover 機制:東岸的應用伺服器仍大量呼叫已經切到西岸的 MySQL 叢集,每次資料庫呼叫都多一趟跨美國大陸的往返延遲。43 秒的網路分區最終演變成 24 小時 11 分鐘的服務降級,且兩地叢集一度逼近 split-brain 邊緣。GitHub 選擇犧牲部分可用性保資料一致,暫停 webhook 派送與 GitHub Pages 建置,而不是讓兩地繼續各自接受寫入。這正是本節反覆強調的重點:failover 機制與應用層的延遲、一致性假設必須是同一份合約的兩面,不能分開設計。GitHub 的事後分析完整記錄了這次事故。
自動 failover 很吸引人,但先能安全手動切換更重要。你至少要知道:誰有權切、怎麼確認 primary 真的失敗、切後如何避免雙寫、如何回到 primary。這些答案不在 Kubernetes 或雲端服務的按鈕裡。
這個順序常常被跳過,因為「自動化」聽起來就是比較先進的做法。但自動 failover 本質上是把「手動切換的判斷邏輯」寫成程式碼,如果連人都還不確定切換的判斷條件、切換後的驗證步驟、回切的責任歸屬,把這些寫成自動化程式碼,只是把「不確定」用更快的速度執行出來而已。手動先行,不是因循守舊,而是先讓人類的判斷邏輯被講清楚、被寫下來、被演練過,自動化才有東西可抄。
Netflix 在 2012 年一次 AWS us-east-1 大規模停機後,改採 active/active 多區域架構,但沒有停在「架構圖畫完」這一步,他們接著做 Chaos Monkey(隨機關一台機器)、Chaos Gorilla(關一整個 AWS availability zone)、Chaos Kong(模擬整個 region 消失),持續在生產環境驗證切換是否真的按預期發生。
這三個工具的名字排列本身就是一份很好的教材,因為它們對應的是三種不同粒度的「唯一」:
Chaos Monkey → 驗證:單一 instance 消失,服務還能不能撐住
Chaos Gorilla → 驗證:整個 availability zone 消失,跨 AZ 的備援還可不可靠
Chaos Kong → 驗證:整個 region 消失,跨 region 的 failover 是否真能接手
如果只做過 Chaos Monkey 等級的演練,你驗證到的其實只是「多開了幾台機器」這件事有沒有用;region 等級的假設,DNS 切換、跨區資料一致性、控制面在 region 消失時是否還能運作,完全沒有被驗證過。Netflix 選擇把這三個等級都做成常態化、隨時可能在生產環境觸發的演練,正是因為他們意識到:沒被驗證過的 failover,跟不存在的 failover 沒有差別;這也是為什麼手動演練要先於自動化。
值得強調的是,Netflix 並不是先把「自動 failover」做完美才開始做 Chaos 工具,而是反過來,先確保架構在被隨機破壞時「不會整個垮掉」,再逐步把手動應變的動作自動化。這個順序跟本節標題呼應:先能安全手動撐住,再談自動化接手。
不管是不是 Netflix 等級的常態化混沌工程,一次手動 failover 演練值得留下的紀錄至少包含:
1. 觸發前狀態
誰在什麼時間、對哪個元件、用什麼方式模擬故障
2. 判斷依據
當下是根據哪些訊號(不是憑印象)判斷「該切」
3. 執行過程
誰執行了切換、花了多久、中間是否卡在等待別人授權
4. 回切與清理
什麼時候確認 primary 恢復、什麼條件下才把流量切回去
這四份紀錄本身就是 runbook 的雛形。很多團隊會先寫 runbook 再做演練,但順序反過來通常更誠實:先做一次演練,把演練中真實發生的猶豫、卡關、忘記通知誰的環節都記下來,runbook 才會反映真實世界會發生的狀況,而不是工程師憑想像寫出的理想流程。
手動演練做過一兩次、四份紀錄的格式也穩定下來之後,值得把它從「一份 Markdown 筆記」升級成「一份結構化的定義檔」,不是為了自動化執行,而是為了讓演練本身變得可重複、可比較。下面是一個最小示意,格式借用了 chaos engineering 社群常見的實驗定義寫法:
experiment: manual-failover-primary-llm
target: primary-model-provider
hypothesis: >
當 primary provider 的 timeout ratio 超過門檻時,
policy_question 與 document_summary 類型的請求會在 5 秒內
切換到 fallback provider,並在回應中標記 degraded=true。
method:
- 使用 fake provider(見本篇 ⑦ 節)模擬 primary 持續 timeout
- 手動確認 router 是否依 ④ 節決策表做出對應動作
- 記錄實際切換耗時、fallback 回應內容、telemetry 是否完整
abort_conditions:
- 任何 permission_change 類任務被自動切換到 fallback
- fallback 回應未標記 degraded
rollback: 停止模擬故障,確認 primary 恢復健康訊號後手動回切
owner: (填入負責演練的人)
last_run: not yet performed
把演練寫成這種格式,好處不在於格式本身多漂亮,而在於它逼著你把 ④ 節那張「待量測」的表格,跟一次「真的執行過的實驗」綁在一起,hypothesis 欄位對應決策表裡的門檻與預期行為,abort_conditions 欄位直接對應決策表「不做什麼」那一欄。這份定義檔不需要一開始就自動化執行;先讓它成為一份「下次演練時照著做」的清單,就已經比純敘述式的筆記更容易被檢查、被重複、被交接給下一個接手的人。Day 30 談到正式的 chaos experiment 時,會延伸這個結構,加上自動觸發與自動驗證的機制。
紙上的決策表比程式碼更早該存在。原因很直接:程式碼描述「系統會怎麼做」,決策表描述「系統應該怎麼做」,如果兩者對不上,代表程式碼裡藏著一個沒有人明確做過的決定。與其在 code review 時逐行猜測某個 except 分支的意圖,不如先把意圖攤開寫成表格,再對照程式碼是否真的照表操作。
表格的五個欄位不是隨便選的,各自對應一個容易被省略的問題:
情境 → 這是什麼樣的失敗?(不是籠統的「壞了」)
偵測條件 → 用什麼訊號判斷「真的發生了」?(不是憑印象)
選擇 → 系統該做什麼?(重試/切換/拒絕,三選一)
使用者結果 → 使用者具體會看到什麼?(不是「有錯誤處理」這種空話)
不做什麼 → 這個情境下絕對禁止的動作是什麼?
最後一欄「不做什麼」常被省略,卻往往是最重要的一欄。「選擇」欄回答的是「該做什麼」,容易寫得四平八穩;「不做什麼」逼你面對真正的風險,例如「不重送非冪等 tool action」這句話,直接點出如果沒有這條禁令,系統在切換 fallback 時很可能會把一個已經執行過的付款動作再執行一次。
為一個 dependency 寫下這張表。
| 情境 | 偵測條件 | 選擇 | 使用者結果 | 不做什麼 |
|---|---|---|---|---|
| primary LLM timeout 升高 | timeout ratio 超過你定義的門檻 | 對可降級查詢切到 fallback | 標示較慢或能力受限 | 不重送非冪等 tool action |
| vector index 不可用 | health check 與 query failure 都成立 | 回 insufficient_context |
不提供未引用答案 | 不假裝有查到資料 |
| active/passive primary 不健康 | primary failure 已被獨立 health signal 確認 | 切到已同步的 passive | 短暫拒絕或受控降級 | 未確認前雙寫 |
| active/active 節點失敗 | 單一節點 error 與 routing health 同時成立 | 將流量移出該節點 | 其餘節點持續服務 | 把衝突寫入直接重送 |
| 控制面/服務發現(如 DNS、config service)異常 | 多個看似獨立的下游同時開始失敗,且都指向同一個解析或設定來源 | 切到已知良好的靜態設定或備援解析路徑(若有),否則明確標示服務降級 | 清楚的「服務暫時降級」訊息,而非各自為政的個別錯誤 | 讓每個下游各自重試,掩蓋根因其實在控制面 |
最後一列刻意加進 ⑥ 節談過的控制面情境,是因為它跟前四列的性質不太一樣:前四列的偵測條件都聚焦在單一元件(某個 provider、某個 index、某個節點),控制面異常的偵測條件卻是「多個看似獨立的下游同時開始失敗」,這正是 AWS DNS 事故與 Google Cloud Service Control 事故共同的癥狀:一開始看起來像是好幾個服務各自出了問題,實際上是同一個根因透過共用的控制面同時發作。把這一列明確寫進決策表,能提醒值班人員在看到「好幾個地方同時紅燈」時,第一個該問的問題不是「哪個服務先修」,而是「這些服務是不是共用了同一個我們還沒盤點過的元件」。
門檻尚未量測就寫「待量測」,不要借用範例數字。接著畫出切換前後的資料流,確認 authorization、observability 與回切也有路徑。
寫決策表時最大的誘惑是去網路上找一個看起來權威的數字填進「偵測條件」,例如看到某篇文章寫「timeout ratio 超過 5% 就該切換」,就原封不動抄進自己的表格。這個數字可能對那篇文章的系統成立,對你的系統完全不成立:你的使用者忍耐度、你的下游 timeout 設定、你的 provider SLA,全部跟那篇文章的作者不一樣。
把「待量測」原封不動留在表格裡,會逼著這件事在下一步被排進待辦清單,而不是被一個看似合理、實則來路不明的數字悄悄蓋過去。這張表格會被後面的路由程式、告警規則直接引用,一旦引用的是憑空想像的門檻,之後的每一層都會繼承同一個錯誤,而且因為「表格看起來很完整」,這個錯誤反而更難被發現。
在隔離環境可用兩個 fake provider 完成手動 failover 演練:primary 一律丟出 timeout,fallback 回固定、明確標示為測試的結果。記錄切換前後的 provider identifier、outcome 與 trace;再將 primary 恢復後確認回切規則。不要用真實 provider 故障當測試開關。
本文未執行這項演練,也沒有驗證任何 provider failover。
這份決策表寫完之後,值得問自己最後一個問題:如果把這張表拿給一個完全沒參與過這個系統設計的工程師看,他能不能單靠這張表,正確判斷某個新情境該怎麼處理?如果答案是「還需要問我才知道」,代表表格裡還有隱含的假設沒有被寫下來。這個測試不需要真的找人來測,自己重讀一遍、假裝自己是第一次看到這個系統的人,通常就能抓出漏洞。
一套 failover 機制的第一個輸出不該是程式碼,而是 failure contract。它回答的是:某依賴失效時,系統承諾什麼、明確不承諾什麼。這能阻止團隊在 incident 當下用「再試一次」代替判斷。
用「合約」這個詞是刻意的。策略(strategy)暗示這是工程團隊內部的技術選擇,可以隨時因為新想法而調整;合約(contract)暗示這是對使用者、對其他團隊、對法務或風控的承諾,改動之前要走過審查,而不是某個工程師在 pull request 裡順手調整一個 retry 次數。
failure contract 通常不是一份獨立文件,而是分散在三個地方:
產品層: 使用者在依賴失效時,介面上會看到什麼文案、什麼狀態
工程層: router、retry、fallback 的實際行為(也就是本篇要落地的程式碼)
維運層: on-call 在 incident 當下,被允許做什麼、不被允許做什麼
這三層如果沒有共同引用同一份 failure contract,常見的落差是:產品文案寫著「系統會自動為您重試」,工程實作卻只重試一次就直接失敗;或是維運手冊寫著「provider 掛了立即切換」,但沒有人告訴前端要不要顯示降級提示。failure contract 存在的目的,就是讓這三層在同一張表格前對齊。
AI 系統的 failure contract 特別關鍵,因為 LLM provider 的停機歷史比傳統 API 更頻繁。2024–2025 年間,主要 provider 都有過停機事件:Anthropic Claude(4 月 1 小時)、OpenAI chat.completions(11 月 4 小時)、Google Gemini(2 月 40 分鐘)。無 fallback 的團隊在 4 小時 outage 中體驗完整服務中斷;使用確定性 failover 的團隊只體驗到 20–60 秒的故障時間窗。差異不在於「運氣好」,而在於故障合約是否明確寫下。
以公司政策問答服務為例,使用者問的是「我可以每週遠端工作三天嗎?」。primary LLM 失效時,換到能力不同的模型後繼續產生沒有來源的肯定答案,風險高過明確顯示暫時無法回答。
| 工作類型 | primary 失效後的可接受行為 | 不可接受行為 | 理由 |
|---|---|---|---|
| 只讀政策問答 | 切到驗證過的 fallback,或回 insufficient_context |
以猜測補足內容 | 錯誤政策會直接影響使用者決策 |
| 摘要既有文件 | 以相同輸入切換模型,標記降級 | 悄悄改掉輸出結構 | 讀者可接受品質降低,但程式要能解析 |
| 建立工單草稿 | 保存草稿並請使用者稍後重試 | 自動重送建立動作 | 建立動作可能重複,後果不是一個 500 |
| 執行付款、刪除或權限變更 | 拒絕並要求明確重試 | 切 provider 後自動執行 | 安全與 idempotency 優先於可用率 |
這張表不是合規文件的裝飾品。它是後面路由程式、告警規則、runbook 與驗收條件共同引用的決策來源。若某一格填不出來,代表還不應該自動化。
政策問答是一個相對單純的例子,因為它是唯讀的。把同一套思考方式套到一個會執行動作的 agent 上,會冒出更多需要拍板的細節。假設一個 agent 接到「幫我把這張發票標記為已付款」的請求,過程是:理解意圖 → 呼叫「查詢發票」工具 → 呼叫「標記已付款」工具 → 回報結果。primary model 在這中間某一步失效時,失敗合約要回答的問題完全不同於政策問答:
情境:primary 在「查詢發票」之後、「標記已付款」之前失效
可接受行為:
保留已查到的發票資訊,暫停在這一步,
向使用者回報「已找到發票,因系統問題暫時無法標記,請稍後重試」
不可接受行為:
切到 fallback model,讓它憑著對話上下文「猜測」該呼叫哪個工具,
重新觸發一次「標記已付款」,如果 primary 其實已經呼叫成功只是回應遺失,
這會造成重複標記或重複扣款這類真正的業務事故
理由:
「標記已付款」不是冪等操作,重試的風險不是使用者體驗變差,
而是可能製造出財務系統裡真實存在、卻不該存在的紀錄
這個情境凸顯出一個政策問答例子沒有涵蓋的問題:在多步驟 agent workflow 裡,「primary 失效」發生的位置,比「要不要切換」這個決定本身更關鍵。 同樣是 primary 失效,發生在唯讀的「查詢發票」那一步,換一個 model 重新查一次幾乎沒有風險;發生在有副作用的「標記已付款」那一步之後,連 primary 有沒有真的執行成功都無法確定的狀況下,任何形式的自動重試(不管是重試 primary 還是切到 fallback)都可能是在對同一筆帳做第二次操作。這也是為什麼 ④ 節的 decision table 裡,「執行付款、刪除或權限變更」這一列寫的是「拒絕並要求明確重試」,而不是任何一種自動化選項,重試的決定權,在這類操作上要交還給使用者或人工審核,而不是留給路由邏輯自己判斷。
同樣是 model call 沒有成功,處置可能完全不同。把例外名稱直接當成 routing 規則,通常撐不過第一個 provider SDK 升版;先以可觀察的行為分類,才比較穩定。
| 類別 | 例子 | 首選動作 | 為什麼 |
|---|---|---|---|
| transient | connect timeout、短暫 503 | 有上限地重試 primary | 可能只是瞬時擁塞 |
| overload | 明確 rate limit、queue full | 依配額等待、排隊或受控切換 | 立即重試通常只會更糟 |
| permanent request error | 400、schema 不合法、未授權 | 直接失敗並修正請求 | 換 provider 不會修好壞輸入 |
| semantic incompatibility | fallback 不支援 tool schema | 拒絕該工作或改走安全降級 | 技術回應成功仍可能破壞 workflow |
| unknown | 無法分類的 provider error | 以低嘗試次數保守失敗 | 不讓未知錯誤無限循環 |
這裡的「有上限」必須同時有次數和時間兩個限制。只設定 max_attempts=3 不夠:若每次 timeout 是 20 秒,使用者可能已經等了 60 秒,還沒輪到 fallback。
request deadline: 8s
├─ primary attempt 1: 最多 2s
├─ primary attempt 2: 最多 2s,僅 transient 時使用
├─ fallback attempt: 最多 3s,僅允許的任務類型
└─ reserve: 1s 給 parser、validator 與回應
上面的數字只是流程示意,不是建議門檻。實際預算要根據 Day 16 的 latency 分布、使用者耐心與下游 timeout 倒推。時間預算是服務自己的資料,不能從別人的投影片複製。
只設「次數上限」而不設「時間上限」為什麼危險,可以用一個簡單不等式檢查:sum(每次嘗試的 timeout) ≤ request deadline。若 max_attempts=3、每次 timeout 20 秒,總和是 60 秒;但如果 request deadline 是 8 秒,代表第二次嘗試根本不會真的發生,使用者連線早就中斷或上游閘道器先逾時斷開了,重試邏輯自以為還有兩次機會,其實從第一次失敗開始就已經沒有意義。反過來,如果 deadline 抓得足夠寬,max_attempts 又沒有搭配時間上限,重試會一路吃掉使用者的耐心與下游服務的容量,直到某個更上層的 timeout 把整條鏈路砍斷,但那時候使用者已經等了遠超過他願意等的時間。次數與時間必須同時出現在同一張表裡,其中一項空著都不能視為「已定義」。
上面那張錯誤分類表容易被誤讀成「照 exception 類別去 switch-case」。實際上更穩定的分類依據是「可觀察的行為」,而不是「例外叫什麼名字」:
不穩定的分類方式(容易在 SDK 升版後失效):
if isinstance(error, openai.RateLimitError): ...
if isinstance(error, anthropic.APITimeoutError): ...
較穩定的分類方式(依可觀察行為,而非特定 SDK 型別):
status_code in (429,) or "rate limit" in error_signal → overload
status_code in (408,) or is_timeout(error_signal) → transient
status_code in (400, 401, 403, 422) → permanent request error
差別在於:第一種寫法把 router 的正確性綁死在某個第三方 SDK 的例外階層上,SDK 一次不相容升版就可能讓所有分類靜默失效(例如某個例外類別被重新命名或移到不同模組,程式不會報錯,只是分類全部落到 unknown)。第二種寫法依賴的是「狀態碼」「訊息內容」這類跨 SDK 版本相對穩定的訊號,把「這是什麼 SDK 拋出的」和「這代表什麼意思」兩件事分開處理,分類邏輯也才經得起未來的 SDK 升版。
只畫 model provider 往往太樂觀。一次 AI 工作可能還依賴 DNS、egress、secret、prompt registry、vector index、tool policy、輸出 parser 與 audit log。兩個 provider 若共用其中一項,仍可能同時不可用。
「假備援」這個詞刻意選得比較刺,因為它想戳破的正是那種讓人安心的錯覺:架構圖上畫了兩條線,review 會議上大家都點頭說「有備援」,但那兩條線其實在某一層匯流成同一條。找出假備援的方法不是畫更漂亮的圖,而是問一個很煩人、卻沒有捷徑的問題:每一個看起來獨立的方塊,往下追三層,是不是真的獨立?
┌─ Primary model provider
Client → API → Router ┤
└─ Fallback model provider
│
├─ Prompt / policy configuration
├─ Retrieval index
├─ Tool authorization service
├─ Secret / credential source
├─ DNS and outbound network
└─ Trace, log and metric pipeline
接著把每個方塊從「有沒有兩份」改問成「是否獨立失效」。這是很不浪漫、卻最有用的審查方式。
| 元件 | 看起來的備援 | 要追問的共同依賴 | 結論可能是什麼 |
|---|---|---|---|
| 兩家 LLM provider | provider 名稱不同 | 同一 API gateway、同一 egress、同一 secret rotation | 仍有共同故障域 |
| 兩個 API pod | replica 數量為二 | 同一 node pool、同一 database、同一 deployment 設定 | 只能承受單 pod 故障 |
| 兩個 vector index | index 有複本 | 同一 ingestion pipeline、同一 object storage | 新資料或版本可能一起失效 |
| 主備 region | region 名稱不同 | 全域 DNS、身份系統、人工操作路徑 | 切換控制面可能才是 SPOF |
| 兩組憑證 | key 有兩把 | 同一 secrets manager policy | 一次權限誤設就全失效 |
資料面是使用者請求真正經過的路徑;控制面則決定誰能改路由、讀設定、換 secret、執行回切。平時只量資料面,事故時才發現管理介面、DNS 或部署權限失效,工程師就算知道該切換也做不到。
Meta 在 2021 年 10 月的 outage 說明了這種關係:網路骨幹的變更讓 DNS 與資料中心可達性一起受到影響,內部工具也因相依的網路服務而難以使用。閱讀事故報告時,請檢查恢復路徑是否也相依於故障路徑。Meta 的技術說明提供了完整脈絡。
控制面 SPOF 最反直覺的一種樣貌,是「負責告訴你系統壞了的系統」本身和「被監控的系統」共用同一個故障域。2025 年 10 月 20 日,AWS us-east-1 發生一次大規模事故:內部負責監控網路負載平衡的子系統出現異常,導致透過 Route 53 對 DynamoDB 端點的 DNS 解析失敗。任何依賴 DynamoDB 存資料、或依賴 IAM 做身分驗證的無伺服器應用程式,幾乎同時失去服務能力,影響範圍是數千個依賴這條路徑的下游應用程式。
這個案例值得放進「假備援」這一節,是因為它精準示範了控制面 SPOF 最隱蔽的形態:受影響的應用程式本身可能真的有多個運算節點、多個可用區,資料面看起來備援充分,但它們全部要透過同一個 DNS 解析才能找到 DynamoDB 端點。DNS 解析屬於控制面,不是資料面;當控制面失效時,資料面的備援節點再多,也連不到它們原本要服務的資料。
表面上的依賴圖:
API 服務(多個可用區) → DynamoDB(AWS 全代管,理論上高可用)
實際的依賴鏈:
API 服務(多個可用區) → DNS 解析(Route 53) → DynamoDB 端點
↑
這一段是這次事故真正故障的地方
這次事故留給本篇最直接的提醒是:畫依賴圖時,「連到哪個服務」和「怎麼找到那個服務」是兩件必須分開畫的事。後者(DNS、服務發現、憑證解析)常常被當成理所當然存在的基礎設施,直到它自己變成事故的根因。
另一種控制面 SPOF 的樣貌,是一個所有服務都要經過的「守門員」元件本身出錯。Google Cloud 的 Service Control 系統,是所有 Google Cloud API 呼叫在真正執行前都要先經過的配額與權限驗證關卡。2025 年 6 月 12 日,一次沒有經過 feature flag 保護就直接上線的配額政策功能,在遇到特定資料表裡帶有未初始化欄位的紀錄時,觸發了 null pointer 例外,進入重複崩潰的迴圈。
因為幾乎所有 Google Cloud API(Compute、Storage、Container 等)都要先問過 Service Control「這個請求可以放行嗎」,Service Control 一旦答不出來,這些原本彼此獨立、分屬不同產品團隊維護的服務,就在同一時間一起開始回傳 5xx,事件持續超過兩小時,波及 Spotify、Discord、Snapchat 等大量下游客戶。
表面上互相獨立的服務:
Compute API Storage API Container API ...(各自的程式碼、各自的團隊)
實際共用的守門員:
Compute API ─┐
Storage API ─┼─→ Service Control(配額/權限驗證)
Container API─┘
↑
這一層出錯,上面全部一起倒
這個案例補上了 AWS DNS 案例沒有涵蓋的另一半教訓:控制面 SPOF 不只發生在「找路徑」這種基礎設施層級,也可能發生在「每個請求都要先問過」的業務邏輯層級,任何一個被所有服務共同呼叫、用來做驗證或授權判斷的內部服務,都值得在依賴圖上被單獨標記出來,而不是被畫進某個服務自己的方塊裡,被當成它私有的實作細節。放到 AI 系統的語境,這正好對應到本篇第一節提過的「tool authorization service」,如果一個 agent 平台上所有的 tool call 都要先經過同一個授權服務放行,這個授權服務本身,就是這個平台的 Service Control。
你不需要一開始就有 CMDB。先在架構圖旁標註下列欄位即可:
component: fallback-model-b
data-plane: yes
control-plane dependency: prompt-router-config
failure-domain: provider-b / region-2 / shared-egress-1
state: stateless
last failover exercise: not yet performed
owner: (填入團隊或角色)
最後一欄很重要。not yet performed 是資訊,不是丟臉。把未知寫出來,才不會在 outage 時被架構圖的顏色騙過去。
把這份標記表跟前面 AWS、Google Cloud 兩個案例放在一起看,會發現一個共同的規律:這兩起事故裡,受影響的工程團隊事前大概率都能在「component」「data-plane」這兩欄填出漂亮的答案,多可用區、多節點、看起來備援充分。真正讓事故發生的,是「control-plane dependency」這一欄從來沒有人認真填過。標記表存在的意義,正是逼著這一欄不能被跳過。
把前面幾節的方法論串起來,用一個具體系統走一遍會比抽象規則更好記。假設你在維護一個客服 triage agent:使用者的問題進來,agent 判斷分類、查詢知識庫、視需要呼叫「建立工單」工具,最後回覆使用者。先畫出完整依賴圖:
使用者訊息
│
▼
API Gateway(雲端代管,多可用區)
│
▼
Triage Agent 服務(兩個 replica)
├─→ Primary LLM provider
├─→ Fallback LLM provider
├─→ 知識庫 vector index(單一 region)
├─→ 工單系統 tool(外部 SaaS,單一 API key)
├─→ Prompt / policy 設定(單一 config service)
└─→ Trace/log/metric pipeline(單一 collector)
接著逐一套用 ⑥ 節那句「往下追三層,是不是真的獨立」:
| 元件 | 表面備援 | 追三層之後發現的共同依賴 | 結論 |
|---|---|---|---|
| Triage Agent 兩個 replica | 有 | 同一個 config service 提供 prompt、同一組 API key 連工單系統 | 計算層有備援,但決策內容與外部授權仍是單點 |
| Primary/Fallback LLM | 有 | 兩者都透過同一個內部 egress proxy 出站 | egress proxy 掛掉時兩個 provider 都連不上,備援形同虛設 |
| 知識庫 vector index | 沒有明說 | 單一 region、單一 ingestion pipeline | 這個 region 的網路問題,會讓 retrieval 直接失效,而不是效能下降 |
| 工單系統 tool | 沒有明說 | 單一 API key,且該 key 快到期日期沒人追蹤 | 憑證過期會讓「建立工單」這個有副作用的操作突然全部失敗 |
| Prompt / policy 設定 | 沒有明說 | 是 ⑥ 節談的控制面,兩個 replica 都讀同一份 | 設定服務掛掉時,即使 replica 都活著,也不知道該怎麼問、能不能用工具 |
這張表跑完一輪,會發現「兩個 replica」這個原本讓人安心的事實,只解決了表格裡五分之一的風險。egress proxy、vector index 的 region、工單系統的 API key、prompt 設定服務,每一個都值得回到 ④ 節的 failover decision table 裡,明確寫下「這個元件失效時,使用者會看到什麼、系統該做什麼、禁止做什麼」。這正是本篇想強調的順序:先把依賴圖攤開、找出假備援,再回頭去填決策表,而不是反過來,先寫一堆 fallback 程式碼,再指望架構圖自動跟著補齊。
下篇會把這份決策表接回可執行的 fake-provider router,串進 metrics、logs、traces 三種可觀測性訊號,並整理三個常見的 failover 反例與一份設計審查清單。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.